Skip to content

Do not treat method_exists() as always true for @method-annotated methods#5664

Open
phpstan-bot wants to merge 10 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6o6iiqe
Open

Do not treat method_exists() as always true for @method-annotated methods#5664
phpstan-bot wants to merge 10 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6o6iiqe

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When an interface or class has a @method PHPDoc annotation, PHPStan incorrectly reports method_exists() checks as always evaluating to true. Since @method-annotated methods are virtual (typically handled by __call()) and don't exist as actual PHP methods, method_exists() may return false at runtime.

Changes

  • Modified src/Rules/Comparison/ImpossibleCheckTypeHelper.php:
    • For object types: when hasMethod() returns yes, additionally verify via ClassReflection::hasNativeMethod() that the method is actually declared in PHP code, not just via @method annotation. Return null (indeterminate) instead of true when only a @method annotation exists.
    • For generic class-string types: applied the same hasNativeMethod() guard before reporting always-true.
  • Added test data at tests/PHPStan/Rules/Comparison/data/bug-6211.php covering:
    • @method on an interface, checked via object instance and class string
    • @method on a class directly
    • Native methods still correctly reported as always-true
    • @property with __get() correctly handled (already working via PropertyExistsTypeSpecifyingExtension)

Root cause

ImpossibleCheckTypeHelper::findSpecifiedType() used $objectType->hasMethod() to determine if method_exists() would always return true. hasMethod() delegates to ClassReflection::hasMethod(), which includes methods from AnnotationsMethodsClassReflectionExtension (i.e., @method PHPDoc annotations). Since these annotations describe methods that may be handled by __call() but don't create real PHP methods, method_exists() should not be considered always-true for them.

The fix uses ClassReflection::hasNativeMethod() to verify the method is an actual PHP method before reporting the check as always-true.

Analogous cases probed

  • property_exists() with @property: Already correctly handled — PropertyExistsTypeSpecifyingExtension checks $propertyReflection->isNative() at line 87 and returns empty specifiers for non-native properties.
  • method_exists() with class-string: Fixed in the same PR — the generic class-string code path in ImpossibleCheckTypeHelper had the same issue.

Test

Regression test testBug6211 in ImpossibleCheckTypeFunctionCallRuleTest with test data covering @method on interfaces and classes, both via object instances and class strings, alongside native methods that should still be reported as always-true.

Fixes phpstan/phpstan#6211

… methods

- In `ImpossibleCheckTypeHelper::findSpecifiedType()`, the `method_exists()`
  check used `hasMethod()` which includes `@method` PHPDoc annotations.
  Since `@method` methods are virtual (handled by `__call()`), they don't
  guarantee `method_exists()` returns true at runtime.
- Added `hasNativeMethod()` check for object types to only report always-true
  when the method is actually declared in PHP code, not just via `@method`.
- Applied the same fix for the generic class-string code path.
- Verified that `property_exists()` already handles `@property` correctly
  via `PropertyExistsTypeSpecifyingExtension::isNative()` check.
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule should still report a method_exists already specified to true.

// @method should not make method_exists always true
if (\method_exists($hell, 'isTrue')) {
    if (\method_exists($hell, 'isTrue')) { // This one should be reported

     }
}

Also if we finally check native type ; is it still needed to check hasMethod($methodType->getValue())->yes() before ?

When inside an `if (method_exists(...))` block, a repeated
method_exists() check for the same object and method is redundant and
should be reported as always-true, even for @method-annotated methods.

To detect this, the MethodExistsTypeSpecifyingExtension now also tracks
the method_exists() FuncCall expression as ConstantBooleanType(true) in
the scope (in addition to narrowing the argument type). This allows
ImpossibleCheckTypeHelper to detect the nested case via
Scope::hasExpressionType() before any type-specific logic runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of the changes made to address the review:

Review point 1: Nested method_exists() should still be reported when already specified to true

Two coordinated changes:

  1. MethodExistsTypeSpecifyingExtension: Now also specifies the method_exists() FuncCall expression as ConstantBooleanType(true) in the scope (via unionWith), in addition to narrowing the argument type. This makes the scope track that method_exists(...) was already established as true inside an if (method_exists(...)) block.

  2. ImpossibleCheckTypeHelper: Added an early $scope->hasExpressionType($node)->yes() check at the top of the constant-method-name block. This detects nested method_exists() calls that were already established by a prior check and returns true (always-true), regardless of whether the method is native or from @method. This handles both object instances and class-string arguments uniformly.

Review point 2: "Is hasMethod() check still needed before hasNativeMethod()?"

Yes, it's still needed. The hasMethod()->yes() guard distinguishes three cases: (a) native method → return true, (b) @method only → return null, (c) method doesn't exist → fall through to hasMethod()->no() which returns false. Without it, @method methods would fall through to general logic which incorrectly reports them as always-true.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the issue exists for property_exists ; if not, why ? Can't the method_exist behave the same way ?

Comment thread src/Rules/Comparison/ImpossibleCheckTypeHelper.php Outdated
…ropertyExistsTypeSpecifyingExtension

Move the @method annotation check into MethodExistsTypeSpecifyingExtension
to match how PropertyExistsTypeSpecifyingExtension handles @Property
annotations. When the method only exists via @method PHPDoc, return empty
SpecifiedTypes so the type is not narrowed. Simplify ImpossibleCheckTypeHelper
accordingly and remove the nested funcCallSpec approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

property_exists is not statisfying cause it reports

if (property_exists($task, $a)) {
			if (property_exists($task, $a)) {
			
			}
		}

but not

if (property_exists($task, '$a')) {
			if (property_exists($task, '$a')) {
			
			}
		}

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

property_exists is not statisfying cause it reports

if (property_exists($task, $a)) {
			if (property_exists($task, $a)) {
			
			}
		}

but not

if (property_exists($task, '$a')) {
			if (property_exists($task, '$a')) {
			
			}
		}

…ethod/@Property

When method_exists() or property_exists() checks a method/property that
only exists via @method/@Property PHPDoc annotation, the type specifying
extensions now store the FuncCall expression as ConstantBooleanType(true)
in scope instead of returning empty SpecifiedTypes. This enables
ImpossibleCheckTypeHelper to detect nested (redundant) checks as
always-true while still not reporting the outer check.

An early check in findSpecifiedType() detects when a FuncCall expression
already has a stored constant boolean type in scope and returns it
directly, bypassing the special-case logic that would otherwise return
null for @method-only methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes:

What the reviewer asked for: Nested method_exists()/property_exists() calls inside a truthy branch should be reported as always-true, even when the method/property only exists via @method/@property PHPDoc annotation. The reviewer pointed out that just returning empty SpecifiedTypes (copying the property_exists pattern) is insufficient because it prevents nested check detection.

Three coordinated changes:

  1. MethodExistsTypeSpecifyingExtension: When a method exists only via @method annotation, instead of returning empty SpecifiedTypes([], []), returns a FuncCall-as-true spec that stores method_exists(...) as ConstantBooleanType(true) in scope. This doesn't narrow the object type (correct — the method might not exist at runtime) but does mark the expression so nested checks can detect it.

  2. PropertyExistsTypeSpecifyingExtension: Same change for @property annotations — returns a FuncCall-as-true spec instead of empty SpecifiedTypes.

  3. ImpossibleCheckTypeHelper::findSpecifiedType(): Added an early check at the top: if the FuncCall node already has a stored constant boolean type in scope (from an outer check), return it directly. This bypasses the method_exists special-case logic that would otherwise return null for @method-only methods. Uses isTrue()/isFalse() instead of instanceof ConstantBooleanType per codebase conventions.

Comment thread src/Rules/Comparison/ImpossibleCheckTypeHelper.php
Comment thread src/Type/Php/PropertyExistsTypeSpecifyingExtension.php Outdated
Comment thread src/Type/Php/MethodExistsTypeSpecifyingExtension.php Outdated
Comment thread src/Rules/Comparison/ImpossibleCheckTypeHelper.php Outdated
phpstan-bot and others added 4 commits May 15, 2026 14:10
…Extension

The hasNativeMethod() check in ImpossibleCheckTypeHelper for generic
class-strings (class-string<Foo>) is still needed because the helper
runs its own hasMethod() check before the TypeSpecifier flow. Without
the guard, method_exists() with @method-only methods on generic
class-strings would be incorrectly reported as always-true.

This commit also adds the same hasNativeMethod() guard to
MethodExistsTypeSpecifyingExtension for generic class-strings, matching
the existing handling for constant strings and object types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pec creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… funcCallSpec creation

The early $scope->hasExpressionType() check in findSpecifiedType() was
unnecessary — nested method_exists()/property_exists() for @method/@Property
annotations are already caught by IfConstantConditionRule through the
funcCallSpec stored in scope. This matches how nested checks with variable
method names are already handled.

Extract funcCallSpec creation into private createFuncCallSpec() methods in
both MethodExistsTypeSpecifyingExtension and PropertyExistsTypeSpecifyingExtension
to avoid duplication and eager creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread tests/PHPStan/Rules/Comparison/data/bug-6211.php Outdated
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-6o6iiqe branch from 1da62a7 to 7838b26 Compare May 15, 2026 14:53
@VincentLanglet VincentLanglet self-assigned this May 15, 2026
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FILE: src/Type/Php/MethodExistsTypeSpecifyingExtension.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
 68 | ERROR | [x] Use early exit to reduce code nesting.
    |       |     (SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed)
--------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Flipped the if ($this->reflectionProvider->hasClass(...)) guard to an early continue at line 68 of MethodExistsTypeSpecifyingExtension.php, resolving the SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed violation. All tests pass and PHPStan reports no errors.

@VincentLanglet VincentLanglet requested a review from staabm May 16, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants